examples: add HyperLiquid-style perp DEX contracts#44
Conversation
Translate the asset-flow and signature semantics from the LayerZero/USDT0
prototype Go scripts (layerzero-usdt0-arkade-demo/internal/scripts/builders.go)
into a four-contract Arkade suite under examples/layerzero/:
- endpoint.ark Endpoint state with receive() + send() transitions,
2-of-2 DVN attestation, receive marker mint,
send marker burn
- oapp.ark OApp state with receive() + send() transitions,
USDT0 mint/burn, marker consumption/emission
- receive_marker.ark Endpoint→OApp invocation marker pinned to the
OApp control singleton
- send_marker.ark OApp→Endpoint invocation marker pinned to the
Endpoint control singleton
Asset-level invariants from the Go spec map directly to OP_INSPECT*ASSET*
opcodes via tx.inputs[i].assets.lookup, tx.outputs[o].assets.lookup, and
tx.assetGroups.find(id).{delta,sumInputs,sumOutputs}. Contract continuation
uses the existing new ContractName(...) covenant. Packet-level invariants
(OP_INSPECTPACKET / OP_SUBSTR / OP_BIN2NUM / OP_INSPECTINPUTARKADESCRIPTHASH)
that the Arkade compiler does not yet expose are documented in each file and
delegated to the introspector runtime — see examples/layerzero/README.md
for the mapping table.
Also:
- Register the layerzero project in the playground sidebar.
- Add tests/layerzero_test.rs (14 tests) that pin the key invariants:
DVN signature checks, marker mint/burn via group sums, state continuation
via OP_INSPECTOUTPUTSCRIPTPUBKEY, and control-asset singleton checks in
the markers.
Brings the Arkade compiler in line with the canonical introspector opcode set (https://github.com/ArkLabsHQ/introspector). Adds opcode constants for everything the introspector documents, then wires grammar/parser/compiler/ typechecker for the subset needed by the LayerZero / USDT0 demo so the contracts in examples/layerzero/ can express packet-level invariants natively instead of delegating them to the runtime. New language surface -------------------- tx.packet(packetType) → OP_INSPECTPACKET <1> OP_EQUALVERIFY tx.inputs[i].packet(packetType) → OP_INSPECTINPUTPACKET <1> OP_EQUALVERIFY substr(data, off, size) → OP_SUBSTR cat(a, b) → OP_CAT bin2num(data) → OP_BIN2NUM num2bin(value, size) → OP_NUM2BIN size(data) → OP_SIZE OP_NIP tx.inputs[i].arkadeScriptHash → OP_INSPECTINPUTARKADESCRIPTHASH tx.inputs[i].arkadeWitnessHash → OP_INSPECTINPUTARKADEWITNESSHASH tx.id → OP_TXID Files ----- src/opcodes/mod.rs add OP_INSPECTPACKET, OP_INSPECTINPUTPACKET, OP_INSPECTINPUT(ARKADESCRIPTHASH|ARKADEWITNESSHASH), OP_TXID, OP_CAT, OP_SUBSTR, OP_LEFT, OP_RIGHT, OP_BIN2NUM, OP_NUM2BIN, OP_SIZE, OP_EQUALVERIFY, OP_NUMEQUALVERIFY, OP_SWAP, plus the bitwise and extra-arithmetic opcodes listed by the introspector README so they're available to future emission paths. src/parser/grammar.pest new rules: substr_func, cat_func, bin2num_func, num2bin_func, size_func, packet_inspect, input_packet_inspect; new properties on tx_introspection (id) and input_introspection (arkadeScriptHash, arkadeWitnessHash). src/models/mod.rs new Expression variants Substr, Cat, Bin2Num, Num2Bin, SizeOf, PacketInspect, InputPacketInspect. src/parser/mod.rs parse functions and dispatch entries for primary and complex (require-context) expressions. src/compiler/mod.rs emission in both generate_expression_asm and emit_expression_asm; introspection-detection updated so the new variants force the N-of-N exit-path policy. src/typechecker/mod.rs infer Bytes / Uint64Le / Int / Bytes32 for the new variants and new introspection properties. Tests ----- tests/packet_primitives_test.rs (10 tests) pins each new primitive to its canonical opcode and verifies emission shape (e.g. tx.packet asserts presence via "OP_1 OP_EQUALVERIFY"; size() drops the source bytes via OP_NIP). Full suite: 136 passed, 0 failed (was 126).
… rewrite
Round-2 follow-up to the introspector-primitive commit. Extends the grammar
so byte-producing primitives can flow into the existing comparison rules,
then rewrites the four LayerZero / USDT0 contracts to express the full
Go-script semantics natively instead of delegating packet-level checks to
the runtime.
Grammar / parser
----------------
- new comparison shape `byte_expr_comparison`:
(sha256|substr|cat|bin2num|num2bin|size) <op> (<rhs>)
where <rhs> is another such term, a tiny byte_expr_arith
(e.g. `group.sumOutputs + bin2num(substr(...))`), an identifier,
or a number literal.
- hash_comparison RHS broadened from `identifier` to also accept
substr/cat/num2bin, so `sha256(substr(...)) == substr(...)` parses.
Legacy `sha256(x) == y` (htlc) still hits the fast-path HashEqual.
- asset_lookup_comparison, group_property_comparison, and
group_property_arith_expr now accept bin2num(...) on the RHS, so
contracts can balance an asset delta against a packet field.
- input_introspection_comparison / output_introspection_comparison now
accept substr(...) on the RHS, so LayerZero OApp.receive() can pin
the recipient output's x-only key to a CreditMessage byte slice.
- byte_value rule lets substr / cat / bin2num / size accept packet
introspection and input/output introspection results as their byte
argument (recursive PEG, terminates on terminals).
- new Expression::Sha256 variant for inline hashing inside comparisons.
Compiler
--------
- emit OP_PUSHCURRENTINPUTINDEX for `this.activeInputIndex` and
OP_INPUTBYTECODE for `this.activeBytecode` (was placeholder before).
- Expression::Sha256 emits `<data> OP_SHA256`.
LayerZero contracts (now packet-native)
---------------------------------------
endpoint.ark
- Endpoint state v1 / size 183, LzReceive v1 / size 219, DVN
attestation v1 / size 228 — checked with size(tx.packet(t)) and
substr(tx.packet(t), 0, 1) == 1.
- Route fields (endpointID, oappID, remoteEID, remoteOApp) and DVN
pubkeys pinned via substr(tx.packet(EndpointState), off, len) ==
constructor_param.
- DVN attested-hash binding via
sha256(substr(tx.packet(LzReceive), 1, 140))
== substr(tx.packet(DvnAttestation), 1, 32)
and checkSigFromStackVerify over both DVNs.
- Embedded CreditMessage hash binding
sha256(substr(LzReceive, 145, 74)) == substr(LzReceive, 109, 32).
- Receive marker pinned to `new ReceiveMarker(receiveMarkerScriptHash,
oappCtrlAssetId, exit)`.
- send(): LzSend v1 / size 181, OAppSendInvocation read via
tx.inputs[1].packet(20), per-field invocation↔LzSend equality, and
LzSend.GUID = sha256(invocation).
oapp.ark
- receive(): reads LzReceive from tx.inputs[0].packet(17), pins
recipient output's scriptPubKey to substr(packet, 147, 32),
credits USDT0 via usdt0Group.delta == bin2num(substr(packet,179,8)).
- send(): emits OAppSendInvocation, burns USDT0 by
usdt0Group.sumInputs == sumOutputs + bin2num(substr(packet, 103, 8))
and pins the send marker output to
`new SendMarker(sendMarkerScriptHash, endpointCtrlAssetId, exit)`.
receive_marker.ark / send_marker.ark
- this.activeInputIndex == 0/1 (OP_PUSHCURRENTINPUTINDEX equality).
- tx.inputs[stateIdx].arkadeScriptHash == oappReceive / endpointSend
ScriptHash (OP_INSPECTINPUTARKADESCRIPTHASH).
- Control-asset singleton defense-in-depth as before.
Tests
-----
- tests/layerzero_test.rs updated for the new contract shapes: DVN
sigs verified via OP_CHECKSIGFROMSTACKVERIFY; receive uses
OP_INSPECTPACKET / OP_SUBSTR / OP_SHA256; oapp.receive() uses
OP_INSPECTINPUTPACKET and pins recipient pkScript; new
`test_marker_contracts_use_input_arkade_script_hash`.
- Full suite: 138 passed, 0 failed (was 136).
Also refreshes examples/layerzero/*.json artifacts and rewrites the
folder README to document the on-chain enforcement table.
OApp.send() carried `checkSig(ownerSig, ownerPk)` where `ownerPk` was a
function parameter — anyone calling send() could pass any keypair and a
valid signature for it, so the check authenticated no fixed identity. The
Go reference (BuildOAppSendScript) has no such check.
Authority for OApp.send() comes from:
- the OApp control singleton on the spent state input (only the real
OApp state holds it),
- per-UTXO USDT0 input scripts (each owner signs their own input), and
- either the Arkade server cosign (cooperative, serverVariant=true) or
the exit CSV (fallback, serverVariant=false) — both added by the
compiler automatically.
Also removes the now-orphaned `require(amount > 0)` line — the amount is
read from the OAppSendInvocation packet via bin2num(substr(...)), not a
witness.
Test update: replaces "must contain OP_CHECKSIG" with the stronger
invariant "the only OP_CHECKSIG in the server variant is the trailing
server cosign — no contract-level owner sig precedes <SERVER_KEY>".
Suite: 138 passed, 0 failed.
Newer rustc (1.94.x on CI ubuntu-latest) rejects what older versions accepted: `cli.input.expect(...)` partially moves `cli.input`, then a later `&cli` borrow of the whole struct fails with E0382. Cloning the PathBuf (small, only a few bytes overhead) keeps `cli` intact for the subsequent borrows. Pre-existing bug surfaced by the toolchain upgrade — the arkade-bindgen subcrate was unchanged by the LayerZero work, but CI runs `cargo test --verbose` from the workspace root which compiles all members. Locally with `cargo test` (single-package), bindgen was never re-compiled so the issue stayed latent. Workspace test suite: 158 passed, 0 failed.
PR #25 (now on master) added tests/compilation_roundtrip_test.rs which sweeps every examples/**/*.ark file and asserts each function variant (serverVariant=true and =false) has a non-empty witnessSchema. CI was failing on the merge with master because OApp / ReceiveMarker / SendMarker had no constructor pubkeys and no function signature parameters, producing an empty witnessSchema for the exit variant: just `<exit> OP_CHECKSEQUENCEVERIFY OP_DROP`. Without a constructor pubkey, the unilateral exit path is effectively "anyone may force-spend after the CSV timelock" — which is broken: any party could force-recover stuck OApp / marker state and produce a continuation transaction by themselves. Fix: add `pubkey operatorPk` to OApp, ReceiveMarker, SendMarker, and Endpoint constructors. The operator is the off-chain LayerZero / USDT0 relay entity and is the only N-of-N exit-witness participant. The cooperative server-cosigned path is unchanged: DVN attestations + packet introspection + OApp control singleton do all the authorisation work, exactly as the Go reference (BuildOAppReceiveScript / BuildOAppSendScript) specifies — operatorPk does NOT appear in any function body. Endpoint already had dvn0Pk + dvn1Pk in its constructor (so its exit witness was non-empty), but operatorPk is added there too for symmetry — the same operator can recover stuck Endpoint state, and Endpoint now passes operatorPk through to `new ReceiveMarker(...)` so all four contracts share one operator identity. Locally: cargo test # 138/138 (was 138/138) git merge --no-commit --no-ff origin/master && cargo test --test compilation_roundtrip_test # now passes
This reverts commit aa49711.
When a contract uses introspection and has no constructor- or function-
supplied pubkeys, the exit-path N-of-N CHECKSIG chain is empty, so the
emitted exit script is just `<exit> OP_CHECKSEQUENCEVERIFY OP_DROP` and
the witnessSchema is empty. That means "anyone may force-spend after the
CSV timelock" — a broken unilateral exit shape.
Fix the issue at the compiler level rather than asking every contract
author to declare a placeholder pubkey:
asm when all_pubkeys.is_empty() and the exit variant is being
generated, emit
<OPERATOR_KEY> <operatorSig> OP_CHECKSIG
in front of the CSV. Same auto-injection pattern as the
<SERVER_KEY> placeholder used for the cooperative path.
witnessSchema add `operatorSig` (signature, schnorr-64).
function ABI push an `operatorSig` FunctionInput so SDK bindgen
surfaces it as a required witness.
require emit `nOfNMultisig` with the message
"operator signature required (auto-injected exit fallback)".
The placeholder `<OPERATOR_KEY>` is resolved by the runtime / wallet
exactly like `<SERVER_KEY>` is — `.ark` source never mentions it.
Contracts that already have constructor pubkeys (htlc, fuji_safe,
nft_mint, price_beacon, …) are unaffected: the empty-pubkey branch is
not taken, so their exit asm and witnessSchema are byte-identical to
before.
Reverts the previous LayerZero workaround commit ("add operatorPk to
constructor"). The four LayerZero contracts are back to their clean
shape and now compile through the master `compilation_roundtrip_test`
because each variant has a non-empty witnessSchema (cooperative:
serverSig; exit: operatorSig).
Local: 138 passed, 0 failed. Merge-with-master: 227 passed, 0 failed.
…it witness" This reverts commit 369c4aa.
…ntracts-spec-OdxcF
Per CLAUDE.md and the compiler spec:
- <SERVER_KEY> is auto-injected on the COOPERATIVE path; that's the only
auto-injected key.
- The UNILATERAL exit path is "N-of-N CHECKSIG over the sum of all
pubkeys in the constructor". When that sum is zero (no constructor
pubkeys), the exit path collapses to pure CSV — an empty witness is
the intended, correct shape for a fully-permissionless contract.
PR #25's compilation_roundtrip_test asserted `!witness_schema.is_empty()`
unconditionally on every variant, which baked in an implicit assumption
that every contract has at least one constructor pubkey. That broke for
the new permissionless LayerZero markers + OApp.send / OApp.receive,
which intentionally have no signer.
Changes:
tests/compilation_roundtrip_test.rs
Tighten the assertion so only the cooperative variant must have a
non-empty witnessSchema (at minimum the auto-injected serverSig).
Exit variants may be empty when there are no constructor pubkeys.
Docstring updated with the rationale and a pointer to CLAUDE.md.
src/validator/mod.rs
Stop emitting the "empty witnessSchema" warning on exit variants for
the same reason. The validator now warns only when the cooperative
variant is missing serverSig (a real compiler bug).
LayerZero contracts revert to their natural shape:
Endpoint (has dvn0Pk + dvn1Pk in ctor) exit = 2-of-2 over DVN keys + CSV
OApp exit = pure CSV
ReceiveMarker exit = pure CSV
SendMarker exit = pure CSV
This commit also reverts ce369c4aa (auto-injecting <OPERATOR_KEY> on the
exit path) — that was a misread of the spec; the operator/server key is
only on the cooperative path, never the exit one. See the preceding
revert commit (18a407e).
Local: 227 passed, 0 failed (full merged-with-master suite).
…ntracts-spec-OdxcF # Conflicts: # playground/main.js # src/compiler/mod.rs
Two correctness fixes plus README clarifications, from Arkana's round-4
review.
1. OApp.receive() recipient pkScript: substr(packet, 145, 34)
The Arkade introspector returns the full scriptPubKey as a single
bytes value (docs/arkade-primitives-spec.md Phase 7 — "outScript(bytes)"),
not the (program, version) two-item split that some Liquid-style
references describe. For a P2TR output that's 34 bytes (0x5120 tag
+ 32-byte x-only key). The old check compared 34 bytes against a
32-byte substr of the CreditMessage, which would never have been
equal. Switching to substr(packet, 145, 34) matches the full P2TR
scriptPubKey including the tag, so the USDT0 credit actually lands
at the recipient committed by the inbound message. Added an inline
comment pointing at the spec reference.
2. Endpoint.receive() witness signature inputs
`attestedHash`, `dvn0Sig`, and `dvn1Sig` were referenced inside the
function body but not declared as function parameters, so they were
emitted as <placeholder>s with no corresponding witnessSchema entry —
the cooperative script would have been unsatisfiable. Added them to
the function signature so the compiler registers them in the
witnessSchema. The body's existing checks already pin attestedHash
to sha256(LzReceive[1..141]) and to DvnAttestation[1..33], so the
prover-supplied value remains tightly bound to canonical state; the
same pattern as htlc.ark's `preimage` and fuji_safe.ark's
`currentPrice` witnesses.
README updates
- Document the "prover supplies witness, contract pins it on chain"
convention used by attestedHash + DVN sigs.
- Document the bytes32 _txid/_gidx decomposition rule (only ids fed
to assets.lookup / assetGroups.find are split; pass-through ids
stay as a single bytes32) — answers Arkana's third question about
oappCtrlAssetId appearing un-split in Endpoint's constructor.
- Expand the nonce-monotonicity note with the off-chain safety net:
DVN replay isn't possible because each DVN signs over the LzReceive
header (which includes the inbound nonce), so a tampered nonce
would require a fresh DVN attestation honest DVNs won't produce.
Suite: 227 passed, 0 failed (merged with master).
The (Expression::Property, "==", Expression::Literal) fast path in generate_comparison_asm was emitting `<lhs> OP_EQUAL <rhs>`, but Bitcoin script requires push-left, push-right, then OP_EQUAL. The existing master examples never hit this branch (their comparisons go through emit_binary_op_asm or dedicated rules like time_comparison / group_property_comparison, all of which emit the correct left-right-op order), so the bug was dormant. The new LayerZero marker contracts use `this.activeInputIndex == 0`, which is the first real consumer of this fast path. Flipping the order makes ReceiveMarker.consume() and SendMarker.consume() execute as intended: before: OP_PUSHCURRENTINPUTINDEX OP_EQUAL 0 after: OP_PUSHCURRENTINPUTINDEX 0 OP_EQUAL Left untouched: the sibling broken-order branches in the same match (Variable==Variable, Variable>=Variable, Property>=Literal, etc.). They remain dormant under the existing example corpus; touching them would expand the diff far beyond the LayerZero PR scope. Filed mentally as a follow-up cleanup. Caught by CodeRabbit (round 5).
- generate_comparison_asm emitted `left OP_EQUAL right` for `Property == Literal`; reorder to `left right OP_EQUAL` and treat the `== true` dummy as a bare introspection push (no spurious OP_EQUAL). - parse_byte_expr_term routed sha256()'s additive_expr child through a per-rule match that always fell through to a Property placeholder; use parse_additive_expr so sha256(substr(...)) emits inline opcodes. - Add regression tests; regenerate affected example ASM. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add two new example contracts under examples/perp/: - perp_position.ark: live perpetual position VTXO with close, liquidate, addMargin, transferPosition, fundingSettle, forceClose - perp_offer.ark: maker order book entry VTXO with fill, partialFill, cancel, update Key design points: - Long/short encoded as isLong flag; short PnL = 2×initial - markValue - Limit order semantics: fill enforces oracle price within [min,max] range - Funding follows StabilityVault model: fundingRatePerSec at 1e12 scale - Permissionless liquidation with liquidationFeeBps reward - Partial fills leave a smaller PerpOffer VTXO (order book semantics) - Both sides margin pooled in a single PerpPosition VTXO - Oracle model matches stability/* contracts: sha256(ticker||price||time)
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Playground PreviewA live preview of this PR's playground is available at:
|
Summary
Adds two new example contracts under
examples/perp/demonstrating a HyperLiquid-style perpetual futures DEX built on Arkade.Contracts
perp_position.ark— live position VTXOHolds the combined collateral of both sides for the duration of the trade.
close()liquidate()addMargin()transferPosition()fundingSettle()initialMarginSats, update rateforceClose()perp_offer.ark— maker order book entry VTXOPre-committed margin with a price range; filled non-interactively.
fill()[minPrice, maxPrice]rangepartialFill()PerpOfferVTXOcancel()update()Design
isLongflag; short PnL =2×initialMarginSats - markValueSatsfill()enforcesminPrice <= oraclePrice <= maxPriceStabilityVaultmodel —fundingRatePerSecat 1e12 scale, applied toinitialMarginSatsliquidationFeeBpsreward to the liquidatorPerpOfferVTXO, enabling order-book-style incremental fillstotalCollateral = makerMargin + takerMargin)stability/*contracts:sha256(ticker || price || time), 600s freshness window